import 'fake-indexeddb/auto';
import { DataStore as DataStoreType } from '../../src/datastore/datastore';
import { Predicates } from '../../src/predicates';
import {
	getDataStore,
	expectIsolation,
	configureSync,
	unconfigureSync,
	pretendModelsAreSynced,
	pause,
} from '../helpers';

let { DataStore } = getDataStore() as {
	DataStore: typeof DataStoreType;
};

/**
 * Renders more complete out of band traces.
 */
process.on('unhandledRejection', reason => {
	console.log(reason); // log the reason including the stack trace
});

describe('DataStore sanity testing checks', () => {
	beforeEach(async () => {
		jest.resetAllMocks();
		jest.resetModules();
	});

	afterEach(async () => {
		await DataStore.clear();
		await unconfigureSync(DataStore);
	});

	test('getDataStore() returns fully fresh instances of DataStore and models', async () => {
		/**
		 * Simulating connect/disconnect and/or `isNode` required the `getDataStore()`
		 * to reset modules. Hence, the returned DataStore instances should be different.
		 */

		const { DataStore: DataStoreA, Post: PostA } = getDataStore();
		const { DataStore: DataStoreB, Post: PostB } = getDataStore();

		expect(DataStoreA).not.toBe(DataStoreB);
		expect(PostA).not.toBe(PostB);

		await DataStoreA.clear();
		await DataStoreB.clear();
	});

	// HAS_MANY does not contain a FK. no constraint to validate.
	test('maintains integrity when attempting to save BELONGS_TO FK at non-existent record', async () => {
		const { DataStore: datastore, Post, Comment } = getDataStore();
		DataStore = datastore;

		await expect(
			DataStore.save(
				new Comment({
					content: 'newly created comment',
					post: new Post({
						title: 'newly created post',
					}),
				}),
			),
		).rejects.toThrow(
			`Data integrity error. You tried to save a Comment`, // instructions specific to the instance follow
		);
	});

	describe('cleans up after itself', () => {
		/**
		 * basically, if we spin up our test contexts repeatedly, put some
		 * data in there and do some things, stopping DataStore should
		 * sufficiently stop backgrounds jobs, clear data, etc. so that
		 * subsequent instantiations are not affected and no rogue, async
		 * errors show up out of nowhere (running queries against connections
		 * or other resources that no longer exist or are not ready.)
		 *
		 * aside from `await stop()`, we're going to be pretty careless with
		 * this test loop (e.g., skipping some awaits) to ensure DataStore
		 * *really* cleans up after itself.
		 */

		/**
		 *                           PAY ATTENTION!
		 *
		 * 1. These tests run at 20x speed using `timeWarp()` to keep running
		 * times reasonable. If this becomes suspect, add a flag to allow
		 * conditional time warping.
		 *
		 * 2. If these tests start failing, run them individually, and then
		 * pick pairs, triads, etc. of tests to run together to find the
		 * smallest possible set of tests that fail together.
		 *
		 * Then, enable "focused logging."
		 *
		 * ```
		 * await expectIsolation(
		 * 	async () => {...},
		 * 	undefined,
		 * 	true                          // <-- this enables "focused logging"
		 * );
		 * ```
		 *
		 * This will start logging messages with timestamps around tests and
		 * each *cycle* generated by `expectIsolation`. And, It will filter out
		 * some less helpful messages generated by the auth package.
		 *
		 * While this is enabled, you can add console.(warn|error|debug) calls
		 * to your tests, and they'll be printed with timestamp prefixes to
		 * help sort out what's happening *when*.
		 *
		 */

		test('sanity check for expectedIsolation helper', async () => {
			// make sure expectIsolation is properly awaiting between executions.
			// Logging is used to provide visual confirmation that the utility is
			// working as expected. We create the same test *N* times.

			// Ths test was originally red-green tested by removing the `await`
			// before the `script(...)` call in `expectIsolation`.

			const numberOfCycles = 5;

			let lastCycle = 0;
			await expectIsolation(async ({ cycle }) => {
				await new Promise<void>(unsleep =>
					setTimeout(() => {
						lastCycle = cycle;
						unsleep();
					}, 20 * cycle),
				);
			}, numberOfCycles);

			expect(lastCycle).toBe(numberOfCycles);
		});

		describe('during lifecycle events', () => {
			let { DataStore, Post } = getDataStore();

			beforeAll(async () => {
				await DataStore.clear();
			});

			afterEach(async () => {
				await DataStore.clear();
			});

			describe('simple cases', () => {
				for (const online of [true, false]) {
					for (const isNode of [true, false]) {
						const connectedState = online ? 'online' : 'offline';
						const environment = isNode ? 'node' : 'browser';

						test(`clearing after awaited start (${connectedState}, ${environment})`, async () => {
							({ DataStore, Post } = getDataStore({ online, isNode }));
							await DataStore.start();
							await DataStore.clear();
							await DataStore.start();
						});

						test(`clearing after unawaited start (${connectedState}, ${environment})`, async () => {
							({ DataStore, Post } = getDataStore({ online, isNode }));
							DataStore.start();
							await DataStore.clear();
							await DataStore.start();
						});

						test(`clearing after unawaited start, then a small pause (${connectedState}, ${environment})`, async () => {
							({ DataStore, Post } = getDataStore({ online, isNode }));
							DataStore.start();
							await pause(1);
							await DataStore.clear();
							await DataStore.start();
						});

						test(`stopping after awaited start (${connectedState}, ${environment})`, async () => {
							({ DataStore, Post } = getDataStore({ online, isNode }));
							await DataStore.start();
							await DataStore.stop();
							await DataStore.start();
						});

						test(`stopping after unawaited start (${connectedState}, ${environment})`, async () => {
							({ DataStore, Post } = getDataStore({ online, isNode }));
							DataStore.start();
							await DataStore.stop();
							await DataStore.start();
						});

						test(`stopping after unawaited start, then a small pause (${connectedState}, ${environment})`, async () => {
							({ DataStore, Post } = getDataStore({ online, isNode }));
							DataStore.start();
							await pause(1);
							await DataStore.stop();
							await DataStore.start();
						});

						test(`starting after unawaited clear results in a DX-friendly error (${connectedState}, ${environment})`, async () => {
							({ DataStore, Post } = getDataStore({ online, isNode }));
							await DataStore.start();
							const clearing = DataStore.clear();

							// At minimum: looking for top-level error, operation that failed, state while in failure.
							expect(DataStore.start()).rejects.toThrow(
								/DataStoreStateError:.+`DataStore\.start\(\)`.+Clearing/,
							);

							await clearing;
						});

						test(`starting after unawaited stop results in a DX-friendly error (${connectedState}, ${environment})`, async () => {
							({ DataStore, Post } = getDataStore({ online, isNode }));
							await DataStore.start();
							const stopping = DataStore.stop();

							// At minimum: looking for top-level error, operation that failed, state while in failure.
							expect(DataStore.start()).rejects.toThrow(
								/DataStoreStateError:.+`DataStore\.start\(\)`.+Stopping/,
							);

							await stopping;
						});
					}
				}
			});

			/**
			 * When fuzzing discovers issues, recreate them here to prevent regressions.
			 */
			describe('edges discovered by fuzz', () => {
				/**
				 * As explained below, DataStore can't actually handle the fuzz yet. :(
				 */
			});

			/**
			 * We're not fuzzable yet ... also, these fuzz tests may need to accumulate
			 * assertions along the way as well, because some of the behavior will
			 * likely change from "everything is happy, DataStore figures it out" to
			 * predictable error cases.
			 */
			describe.skip('fuzz', () => {
				function fuzz() {
					const steps = [] as any[];

					// increase when we can actually deal with the fuzz.
					const stepsToProduce = 3; //  + Math.random() * 10;
					for (let i = 0; i < stepsToProduce; i++) {
						let awaited = true;
						if (Math.random() > 0.5) {
							awaited = false;
						}
						const methods = ['start', 'stop', 'clear', 'query', 'save'];
						const action = {
							method: methods.sort(() => Math.random() - 0.5)[0],
							awaited,
						};
						steps.push(action);
					}
					return steps;
				}

				// increase when we can actually deal efficiently with the fuzz
				for (let i = 0; i < 3; i++) {
					const steps = fuzz();
					const name = steps
						.map(s => `${s.awaited ? 'awaited' : 'unawaited'} ${s.method}`)
						.join(', ');

					for (const online of [true, false]) {
						for (const isNode of [true, false]) {
							const connectedState = online ? 'online' : 'offline';
							const environment = isNode ? 'node' : 'browser';
							const testName = `${name} (${connectedState}, ${environment})`;

							test(testName, async () => {
								({ DataStore, Post } = getDataStore({ online, isNode }));
								for (const step of steps) {
									const f = {
										start: () => DataStore.start(),
										stop: () => DataStore.stop(),
										clear: () => DataStore.clear(),
										save: () => DataStore.save(new Post({ title: testName })),
										query: () => DataStore.query(Post),
									}[step.method];

									if (step.awaited) {
										await f();
									} else {
										f();
									}

									// no explicit assertions for now. at this point, we just
									// want things NOT to blow up. :)
								}
							});
						}
					}
				}
			});
		});

		test('awaited save', async () => {
			await expectIsolation(
				async ({ DataStore, Post }) =>
					await DataStore.save(new Post({ title: 'some title' })),
			);
		});

		test('un-awaited saves', async () => {
			await expectIsolation(async ({ DataStore, Post }) => {
				DataStore.save(new Post({ title: 'some title' }));
			});
		});

		test('queries against locked DataStore are rejected', async () => {
			const { DataStore, Post } = getDataStore();

			// shedule a promise that will NOT be done for "awhile"
			let unblock;

			(DataStore as any).runningProcesses.add(
				async () => new Promise(_unblock => (unblock = _unblock)),
				'artificial query blocker',
			);

			// begin clearing, which should lock DataStore
			const clearing = DataStore.clear();

			// pass control back to handlers briefly, so that `clear()`
			// activities can start occurring.
			await new Promise(unsleep => setTimeout(unsleep, 1));

			// and now attempt an ill-fated operation
			await expect(DataStore.query(Post))
				// looking top-level error name, operation that failed, state DS was in
				.rejects.toThrow(/DataStoreStateError.+DataStore\.query\(\).+Clearing/i)
				.finally(async () => {
					unblock();
					await clearing;
				});
		});

		test('saves against locked DataStore are rejected', async () => {
			const { DataStore, Post } = getDataStore();

			// shedule a promise that will NOT be done for "awhile"
			let unblock;
			(DataStore as any).runningProcesses.add(
				async () => new Promise(_unblock => (unblock = _unblock)),
				'artificial save blocker',
			);

			// begin clearing, which should lock DataStore
			const clearing = DataStore.clear();

			// pass control back to handlers briefly, so that `clear()`
			// activities can start occurring.
			await new Promise(unsleep => setTimeout(unsleep, 1));

			// and now attempt an ill-fated operation
			await expect(
				DataStore.save(new Post({ title: 'title that should fail' })),
			)
				// looking top-level error name, operation that failed, state DS was in
				.rejects.toThrow(/DataStoreStateError.+DataStore\.save\(\).+Clearing/i)
				.finally(async () => {
					unblock();
					await clearing;
				});
		});

		test('deletes against locked DataStore are rejected', async () => {
			const { DataStore, Post } = getDataStore();

			// shedule a promise that will NOT be done for "awhile"
			let unblock;
			(DataStore as any).runningProcesses.add(
				async () => new Promise(_unblock => (unblock = _unblock)),
				'artificial delete blocker',
			);

			// begin clearing, which should lock DataStore
			const clearing = DataStore.clear();

			// pass control back to handlers briefly, so that `clear()`
			// activities can start occurring.
			await new Promise(unsleep => setTimeout(unsleep, 1));

			// and now attempt an ill-fated operation
			await expect(DataStore.delete(Post, Predicates.ALL))
				// looking top-level error name, operation that failed, state DS was in
				.rejects.toThrow(/DataStoreStateError.+DataStore\.delete\(\).+Clearing/)
				.finally(async () => {
					unblock();
					await clearing;
				});
		});

		test('observes against locked DataStore are rejected', async () => {
			const { DataStore, Post } = getDataStore();

			// shedule a promise that will NOT be done for "awhile"
			let unblock;
			(DataStore as any).runningProcesses.add(
				async () => new Promise(_unblock => (unblock = _unblock)),
				'artificial observe blocker',
			);

			// begin clearing, which should lock DataStore
			const clearing = DataStore.clear();

			// pass control back to handlers briefly, so that `clear()`
			// activities can start occurring.
			await new Promise(unsleep => setTimeout(unsleep, 1));

			// and now attempt an ill-fated operation
			DataStore.observe(Post).subscribe({
				next() {
					expect(true).toBe(false);
				},
				error(error) {
					expect(error.message).toContain('DataStoreStateError');
					expect(error.message).toContain('DataStore.observe()');
					expect(error.message).toContain('Clearing');
					unblock();
				},
			});

			await clearing;
		});

		test('observeQueries against locked DataStore are rejected', async () => {
			const { DataStore, Post } = getDataStore();

			// shedule a promise that will NOT be done for "awhile"
			let unblock;
			(DataStore as any).runningProcesses.add(
				async () => new Promise(_unblock => (unblock = _unblock)),
				'artificial observeQuery blocker',
			);

			// begin clearing, which should lock DataStore
			const clearing = DataStore.clear();

			// pass control back to handlers briefly, so that `clear()`
			// activities can start occurring.
			await new Promise(unsleep => setTimeout(unsleep, 1));

			// and now attempt an ill-fated operation
			DataStore.observeQuery(Post).subscribe({
				next() {
					expect(true).toBe(false);
				},
				error(error) {
					expect(error.message).toContain('DataStoreStateError');
					expect(error.message).toContain('DataStore.observeQuery()');
					expect(error.message).toContain('Clearing');
					unblock();
				},
			});

			await clearing;
		});

		test('data stays in its lane for save then query with good awaits', async () => {
			// in other words, a well-formed (awaited) save from one instance
			// does not infect another.
			await expectIsolation(async ({ DataStore, Post, cycle }) => {
				await DataStore.save(new Post({ title: `title from ${cycle}` }));
				const post = await DataStore.query(Post);
				expect(post.length).toEqual(1);
				expect(post[0].title).toEqual(`title from ${cycle}`);
			});
		});

		test('data stays in its lane for save, query, delete all, then query with good awaits', async () => {
			// in other words, a well-formed (awaited) save from one instance
			// does not infect another.
			await expectIsolation(async ({ DataStore, Post, cycle }) => {
				await DataStore.save(new Post({ title: `title from ${cycle}` }));
				const posts = await DataStore.query(Post);
				expect(posts.length).toEqual(1);
				expect(posts[0].title).toEqual(`title from ${cycle}`);

				await DataStore.delete(Post, Predicates.ALL);
				const afterDelete = await DataStore.query(Post);
				expect(afterDelete.length).toEqual(0);
			});
		});

		test('data stays in its lane for basic queries with late un-awaited saves', async () => {
			// in other words, a save from one instance does not infect another.
			await expectIsolation(async ({ DataStore, Post, cycle }) => {
				await DataStore.save(new Post({ title: `title from ${cycle}` }));
				const post = await DataStore.query(Post);

				expect(post.length).toEqual(1);
				expect(post[0].title).toEqual(`title from ${cycle}`);

				// try to pollute the next test
				DataStore.save(new Post({ title: `title from ${cycle}` }));
			});
		});

		test('polite observe() is cleaned up', async () => {
			await expectIsolation(async ({ DataStore, Post, cycle }) => {
				return new Promise<void>(resolve => {
					const sub = DataStore.observe(Post).subscribe(
						({ element, opType, model }) => {
							expect(opType).toEqual('INSERT');
							expect(element.title).toEqual(
								`a title from polite cycle ${cycle}`,
							);
							sub.unsubscribe();
							resolve();
						},
					);
					DataStore.save(
						new Post({ title: `a title from polite cycle ${cycle}` }),
					);
				});
			});
		});

		test('impolite observe() is cleaned up', async () => {
			await expectIsolation(async ({ DataStore, Post, cycle }) => {
				return new Promise<void>(resolve => {
					const sub = DataStore.observe(Post).subscribe(
						({ element, opType, model }) => {
							expect(opType).toEqual('INSERT');
							expect(element.title).toEqual(
								`a title from impolite cycle ${cycle}`,
							);
							// omitted:
							// sub.unsubscribe();
							// (that's what makes it impolite)
							resolve();
						},
					);
					DataStore.save(
						new Post({ title: `a title from impolite cycle ${cycle}` }),
					);
				});
			});
		});

		test('polite observeQuery() is cleaned up', async () => {
			await expectIsolation(async ({ DataStore, Post, cycle }) => {
				await pretendModelsAreSynced(DataStore);
				await DataStore.save(
					new Post({ title: `a title from polite cycle ${cycle} post 1` }),
				);

				const sanityCheck = await DataStore.query(Post);
				expect(sanityCheck.length).toEqual(1);
				expect(sanityCheck[0].title).toEqual(
					`a title from polite cycle ${cycle} post 1`,
				);

				return new Promise<void>(async resolve => {
					let first = true;
					const sub = DataStore.observeQuery(Post).subscribe(({ items }) => {
						if (first) {
							first = false;
							expect(items.length).toEqual(1);
							expect(items[0].title).toEqual(
								`a title from polite cycle ${cycle} post 1`,
							);
							DataStore.save(
								new Post({
									title: `a title from polite cycle ${cycle} post 2`,
								}),
							);
						} else {
							expect(items.length).toEqual(2);
							expect(items.map(p => p.title)).toEqual([
								`a title from polite cycle ${cycle} post 1`,
								`a title from polite cycle ${cycle} post 2`,
							]);
							sub.unsubscribe();
							resolve();
						}
					});
				});
			});
		});

		test('polite observeQuery() with unsynced models is cleaned up', async () => {
			await expectIsolation(async ({ DataStore, Post, cycle }) => {
				console.debug(`before configureSync cycle ${cycle}`);
				await configureSync(DataStore);
				console.debug(`after configureSync cycle ${cycle}`);
				return new Promise<void>(async doneTesting => {
					let first = true;
					const sub = DataStore.observeQuery(Post).subscribe(({ items }) => {
						console.debug(`message received in cycle ${cycle}`, items);
						if (first) {
							first = false;
							expect(items.length).toEqual(0);
							DataStore.save(
								new Post({
									title: `a title from polite unsynced cycle ${cycle} post 1`,
								}),
							);
						} else {
							expect(items.length).toEqual(1);
							expect(items[0].title).toEqual(
								`a title from polite unsynced cycle ${cycle} post 1`,
							);
							sub.unsubscribe();
							doneTesting();
						}
					});
				});
			});
		});

		test('less polite observeQuery() is cleaned up', async () => {
			// i.e., do not unsubscribe in the last step fo the observeQuery
			// event handler.

			await expectIsolation(async ({ DataStore, Post, cycle }) => {
				await pretendModelsAreSynced(DataStore);

				await DataStore.save(
					new Post({ title: `a title from impolite cycle ${cycle} post 1` }),
				);
				const sanityCheck = await DataStore.query(Post);
				expect(sanityCheck.length).toEqual(1);
				expect(sanityCheck[0].title).toEqual(
					`a title from impolite cycle ${cycle} post 1`,
				);

				await new Promise<void>(doneTesting => {
					let first = true;
					const sub = DataStore.observeQuery(Post).subscribe(({ items }) => {
						if (first) {
							first = false;
							expect(items.length).toEqual(1);
							expect(items[0].title).toEqual(
								`a title from impolite cycle ${cycle} post 1`,
							);
							DataStore.save(
								new Post({
									title: `a title from impolite cycle ${cycle} post 2`,
								}),
							);
						} else {
							expect(items.length).toEqual(2);
							expect(items.map(p => p.title)).toEqual([
								`a title from impolite cycle ${cycle} post 1`,
								`a title from impolite cycle ${cycle} post 2`,
							]);

							// missing unsubscribe is what makes it "less polite"
							doneTesting();
						}
					});
				});
			});
		});

		test.skip('impolite observeQuery() is cleaned up', async () => {
			// TODO: observeQuery example that prematurely returns and leaves
			// lingering saves in the pipeline ... not 100% sure if that
			// cleanup is even in-scope for DataStore to clean up.
		});

		test('sync is cleaned up', async () => {
			await expectIsolation(async ({ DataStore, Post, cycle }) => {
				await configureSync(DataStore);

				// save an item to kickstart outbox processing.
				await DataStore.save(
					new Post({ title: `post from "sync is cleaned" up cycle ${cycle}` }),
				);
			});
		});

		test('rude synchronized observe-save is cleaned up', async () => {
			await expectIsolation(async ({ DataStore, Post, cycle }) => {
				await configureSync(DataStore);

				DataStore.observe(Post).subscribe(() => {});

				// save an item to kickstart outbox processing.
				DataStore.save(
					new Post({
						title: `post from "rude synchronized observe-save" up cycle ${cycle}`,
					}),
				);
			});
		});

		//
		// TODO: once we have a clean way to fake a not-node environment, we
		// need an isolation test to prove DataStore shuts **subscription**
		// connections/ops down against mocked AppSync backend.
		//
	});
});
